package edu.usm.cs.roberge;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Implementation of RFC 977
 * 
 * This class was designed to be thread safe, and all operations are synchronous
 * It also allows clients to pass in a listener if they wish to receive progress 
 * updates for longer operations
 * 
 * @author Dave Roberge
 */
public class NewsReaderImpl {
	
	public static ProgressListener EMPTY_PROG_LISTENER = new NullProgressListener();
	
	private Socket socket;
	private final String newsServer;
	private final Logger logger = Logger.getLogger(NewsReaderImpl.class.getName());

	private static final String EMPTY_PARAM = "";
	public NewsReaderImpl(String newsServer) {
		this.newsServer = newsServer;
	}	
	
	public static class NewsReaderException extends Exception {
		private static final long serialVersionUID = 1L;
		
		public NewsReaderException(String message) {
			super(message);
		}
	}
	
	/**
	 * Listener that receives progress update events
	 * for long operations
	 */
	public interface ProgressListener {
		void onNewsGroupCreated(NewsGroup newsGroup);
	}
	
	private static class NullProgressListener implements ProgressListener {
		@Override
		public void onNewsGroupCreated(NewsGroup newsGroup) {
			
		}
	}

	private enum Commands {
		LIST, GROUP, STAT, LAST, HEAD, BODY
	}
	
	/**
	 * LIST command
	 * @param useCache true to use cached results if they are available
	 * @return a listing of news groups
	 */
	public synchronized List<NewsGroup> listNewsGroups(ProgressListener progListener) throws NewsReaderException {
		List<NewsGroup> newsGroups = new ArrayList<NewsGroup>();

		connectToNewsServer();
		BufferedInputStream results = sendCommand(Commands.LIST, EMPTY_PARAM);
		String line = readLine(results);
		if (!line.startsWith("215")) {
			logger.log(Level.WARNING, "Response from LIST command did start with 215");
			throw new NewsReaderException("Unexpected response from server.");
		}
		
		line = readLine(results);
		while(!noMoreText(line, results)) {
			NewsGroup ng = NewsGroup.parseFromString(line);
			progListener.onNewsGroupCreated(ng);
			newsGroups.add(ng);
			
			line = readLine(results);
		}
		
		return newsGroups;
	}

	private void connectToNewsServer() throws NewsReaderException {
		if (socket != null && socket.isConnected()) {
			return;
		}
		
		try {
			socket = new Socket(newsServer, 119);
			// consume welcome message
			readLine(new BufferedInputStream(socket.getInputStream()));
		} catch (UnknownHostException e) {
			logger.log(Level.SEVERE, "Could not find a host with the name: " + newsServer);
			throw new NewsReaderException("Could not connect to news group server: " + newsServer);
		} catch (IOException e) {
			logger.log(Level.SEVERE, "There was an error trying to connect to: " + newsServer);
			throw new NewsReaderException("Could not read or write from the network. Please check your connection.");
		}
	}

	private BufferedInputStream sendCommand(Commands command, String parameter) throws NewsReaderException {
		String strCommand = command.toString();
		
		if (parameter != null && !parameter.isEmpty()) {
			strCommand += " " + parameter;
		}
		
		strCommand += "\r\n";
		try {
			socket.getOutputStream().write(strCommand.getBytes());
			return new BufferedInputStream(socket.getInputStream());
		} catch (IOException e) {
			logger.log(Level.SEVERE, "There was an error sending a command to the news server: " + e.getMessage());
			throw new NewsReaderException("There was an error communicating with the news group server");
		}
	}

	private String readLine(BufferedInputStream results) throws NewsReaderException {
		StringBuffer buffer = new StringBuffer();
		int c;
		try {
			while ((c = results.read()) != '\n') {
				if (c != '\r') {
					buffer.append((char)c);
				}
			}
			
			return buffer.toString();
		} catch (IOException e) {
			logger.log(Level.SEVERE, "Error reading from socket: " + e.getMessage());
			throw new NewsReaderException("There was a problem communicating with the news group server");
		}
	}
	
	/**
	 * GROUP command
	 * 
	 * Select a group from the News Group Server
	 * 
	 * @param newsGroup The news group to select
	 * @return true if selection is successful, false otherwise
	 * @throws NewsReaderException 
	 */
	public synchronized boolean selectGroup(NewsGroup newsGroup) throws NewsReaderException {
		connectToNewsServer();

		BufferedInputStream input = sendCommand(Commands.GROUP, newsGroup.getNewsGroupName());
		String response = readLine(input);
		
		if (response.startsWith("211")) {
			return true;
		}
		
		return false;
	}
	
	/**
	 * Convenience method to directly grab the last message for a news group
	 * 
	 * @param newsGroup the news group to get the message for
	 * @return the last message in news group
	 * @throws NewsReaderException
	 */
	public synchronized Message getLastMessageForNewsGroup(NewsGroup newsGroup) throws NewsReaderException {
		if (!selectGroup(newsGroup)) {
			logger.severe("Could not select news group: " + newsGroup.toString());
			throw new NewsReaderException("Unable to to get information from news group: " + newsGroup.toString());
		}
		
		String message = findLastMessageForGroup(newsGroup);
		
		Message msg = new Message(newsGroup);
		if (MessageUtility.extractMessageIdFromStatResponse(message, msg)) {
			return msg;
		}
		
		throw new NewsReaderException("Unable to read response from News Group server");
	}

	/**
	 * Attempt to retrieve the last (latest) message in a news group
	 * Retrieving the latest message is not always straightforward
	 * The news group server sometimes gives a message number that no longer exist
	 * This method will start with the latest message number, and iterate backwards
	 * until a valid message is found
	 * 
	 * @param newsGroup
	 * @return
	 * @throws NewsReaderException
	 */
	private String findLastMessageForGroup(NewsGroup newsGroup) throws NewsReaderException {
		String message = null;
		int retryCnt = 0;
		int lastMessageNum = Integer.parseInt(newsGroup.getEndMessage());
		
		while (message == null && retryCnt < 50) {
			message = selectMessage(lastMessageNum + "");
			lastMessageNum--;
			retryCnt++;
		}
		
		if (message == null) {
			logger.severe("Could not select last message for news group:" + newsGroup.toString());
			throw new NewsReaderException("Cannot select last message for News Group: " + newsGroup.toString());
		}
		
		return message;
	}
	
	/**
	 * STAT command
	 * 
	 * Move the internal pointer within a news group to a particular message
	 * @param args
	 * @throws NewsReaderException 
	 */
	public synchronized String selectMessage(String messageNumber) throws NewsReaderException {
		connectToNewsServer();
		
		BufferedInputStream input = sendCommand(Commands.STAT, messageNumber );
		String response = readLine(input);
		
		if (response.startsWith("223")) {
			return response;
		}
		
		return null;
	}
	
	/**
	 * Convenience method to move to the previous message in a news group
	 * 
	 * @param newsGroup
	 * @return
	 * @throws NewsReaderException
	 */
	public synchronized Message moveToPreviousMessage(NewsGroup newsGroup) throws NewsReaderException {
		String response = moveToLast();
		if (response == null) {
			throw new NewsReaderException("Could not move to previous message for news group: " + newsGroup.toString());
		}
		
		Message msg = new Message(newsGroup);
		
		if (MessageUtility.extractMessageIdFromStatResponse(response, msg)) {
			return msg;
		}
		
		throw new NewsReaderException("Unable to read response from News Group server");
	}
	
	/**
	 * LAST command
	 * 
	 * Moves the internal message pointer to the previous message
	 */
	public synchronized String moveToLast() throws NewsReaderException {
		connectToNewsServer();

		BufferedInputStream input = sendCommand(Commands.LAST, null);
		String response = readLine(input);

		if (response.startsWith("223")) {
			return response;
		}
		
		return null;
	}
	
	/**
	 * HEAD command
	 * 
	 * Retrieve the header or the meta-data for a message
	 * 
	 * @return true if the message header was read successfully, false otherwise
	 * @throws NewsReaderException 
	 */
	public synchronized boolean getMessageHeader(Message msg, boolean useMsgId) throws NewsReaderException {
		connectToNewsServer();
		String args = "";
		
		if (useMsgId) {
			args = msg.getMessageId();
		}
		
		BufferedInputStream input = sendCommand(Commands.HEAD, args);
		String responseLine = readLine(input);
		if (!responseLine.startsWith("221")) {
			throw new NewsReaderException("Could not get header for message: " + msg.toString());
		}
		
		String headerText = readLine(input);
		while(!noMoreText(headerText, input)) {
			msg.appendToHeader(headerText);
			headerText = readLine(input);
		}
		
		return true;
	}
	
	/**
	 * ARTICLE command
	 * 
	 * Implementation of the ARTICLE command
	 * 
	 * Note: This method is synchronized because it's possible for the user to select multiple
	 * messages at their leisure. Some messages take awhile to read, so it's not safe to invoke this
	 * method until it finishes for each method.
	 * 
	 * @throws NewsReaderException 
	 */
	public synchronized boolean getArticleBody(Message message) throws NewsReaderException {
		connectToNewsServer();
		
		BufferedInputStream input = sendCommand(Commands.BODY, message.getMessageId());
		String responseLine = readLine(input);
		if (!responseLine.startsWith("222")) {
			throw new NewsReaderException("Could not read article for message");
		}
		
		String bodyText = readLine(input);
		while(!noMoreText(bodyText, input)) {
			message.appendToBody(bodyText);
			bodyText = readLine(input);
		}
		
		return true;
	}

	/**
	 * Handle an edge case where the body of an article may have a "."
	 * at the end of the body, but it's not actually the deliminator.
	 * In that case, the first "." on its own line belongs to the body
	 * and should be added to it.
	 * 
	 * @param bodyText
	 * @param input
	 * @return
	 */
	private boolean noMoreText(String bodyText, BufferedInputStream input) {
		try {
			return bodyText.equals(".") && input.available() <= 0;
		} catch (IOException e) {
			return false;
		}
	}
}
